Skip to content

http2: fix stream WINDOW_UPDATE over-crediting buffered body bytes (FLOW_CONTROL_ERROR with slow readers) - #24

Open
rgarcia wants to merge 1 commit into
bogdanfinn:masterfrom
rgarcia:fix-h2-stream-window-overcredit
Open

http2: fix stream WINDOW_UPDATE over-crediting buffered body bytes (FLOW_CONTROL_ERROR with slow readers)#24
rgarcia wants to merge 1 commit into
bogdanfinn:masterfrom
rgarcia:fix-h2-stream-window-overcredit

Conversation

@rgarcia

@rgarcia rgarcia commented Aug 8, 2026

Copy link
Copy Markdown

Fixes the fhttp half of bogdanfinn/tls-client#257.

Problem

When a response body is consumed slower than the origin delivers it (a proxy relaying to a backpressured client, a rate-limited download), a large HTTP/2 download aborts partway with:

stream error: stream ID N; FLOW_CONTROL_ERROR

Root cause

In transportResponseBody.Read, the stream receive-window refresh was:

unsent := int(cc.streamFlow) - int(cs.inflow.available()) + cs.bufPipe.Len()

bufPipe.Len() is body data received but not yet consumed by the application. The credit that is safe to return to the peer is streamFlow - available - buffered (what golang.org/x/net/http2 computes), so the buffered term must be subtracted. Adding it over-credits the stream window by 2 × buffered on every refresh; with a slow reader the buffer stays large, the advertised window desyncs from the real accounting, and the stream dies with FLOW_CONTROL_ERROR once the connection-level window is exhausted.

Fix

One-line sign fix:

-	unsent := int(cc.streamFlow) - int(cs.inflow.available()) + cs.bufPipe.Len()
+	unsent := int(cc.streamFlow) - int(cs.inflow.available()) - cs.bufPipe.Len()

Regression test

TestTransportSlowReaderLargeResponse downloads 48 MiB from an in-process h2 server through a Transport configured with a Chrome-like 6 MiB initial stream window / 15.6 MiB connection flow, consuming the body at ~12 MiB/s.

  • master: fails after ~46 MiB with stream error: stream ID 1; FLOW_CONTROL_ERROR
  • this branch: passes

Test plan

  • go test -vet=off ./http2 -run TestTransportSlowReaderLargeResponse — fails before, passes after
  • go test -vet=off -short ./http2 — no new failures; the TestTransport timeout and TestTransportH2c failure reproduce identically on unmodified master (live-network dependent) and are unrelated to this change
  • Also validated downstream against real origins (197 MiB object, 10 MiB/s reader): truncated at ~30–60 MB before, completes in full after

transportResponseBody.Read computed the stream receive-window refresh as

    unsent = streamFlow - available + bufPipe.Len()

bufPipe.Len() is body data received but not yet consumed by the
application. The amount that is safe to return to the peer is
streamFlow - available - buffered (what golang.org/x/net/http2 computes),
so the buffered term must be subtracted. Adding it over-credits the
stream window by 2x buffered on every refresh.

With a slow reader (proxy relaying to a backpressured client, rate-
limited download), the receive buffer stays large, the advertised stream
window desyncs from the real connection accounting, and a large download
dies partway with:

    stream error: stream ID N; FLOW_CONTROL_ERROR

Add a regression test that downloads 48 MiB over an in-process server
through a Transport configured with a Chrome-like 6 MiB stream window
while consuming the body at ~12 MiB/s. It fails on master with
FLOW_CONTROL_ERROR after ~46 MiB and passes with this fix.

Refs bogdanfinn/tls-client#257
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant